Serenity 整合 Vue3 完整指南 - 以 OPPM 專案管理為例
本文將介紹如何在 Serenity 專案中 整合 Vue3 所開發的應用程式,包含環境設定、CSRF Token 處理、API 設計、前端封裝和實際應用等完整流程。
一、前言:為何選擇 Vue 開發並整合到 Serenity?
技術互補性
Serenity 優勢: 強大的後端框架、完整的權限管理、資料庫 ORM
Vue 優勢: 現代化前端體驗、豐富的生態系統、靈活的元件化開發
開發效率考量
// Vue 的響應式開發效率 const { tasks, updateTask } = useOppmData() // 簡潔的資料綁定和狀態管理 const filteredTasks = computed(() => { return tasks.filter(task => task.name.includes(searchQuery.value) ) })使用者體驗提升
即時互動: Vue 的響應式系統提供即時的 UI 更新
豐富元件: FullCalendar、拖拽看板等現代化 UI 元件
漸進式載入: SPA 架構減少頁面重新載入
維護性優勢
關注點分離: 前端專注於 UI/UX,後端專注於業務邏輯
獨立測試: 前後端可分別進行單元測試
技術棧現代化: 使用 Vue 3、Vite 等現代工具鏈
整合策略
// 透過 API 與 Serenity 後端整合 export const apiClient = createApiClient() // 利用 Serenity 的認證和權限系統 const user = serenityIntegration.getCurrentUser() const hasPermission = serenityIntegration.hasPermission('kanban:write')擴展性考慮
微前端架構: Vue 應用可作為 Serenity 頁面的一部分
漸進式採用: 可逐步將 Serenity 頁面轉換為 Vue 元件
混合開發: 關鍵業務邏輯保持在 Serenity,UI 創新使用 Vue
二、現有 Vue 功能說明與 Domain Model
OPPM (One Page Project Management) 系統概述 OPPM 是一個一頁式專案管理系統,整合了甘特圖、看板、月曆和表格等多種專案檢視模式。該 Vue 3 應用程式提供以下核心功能: 主要功能特色:
多檢視模式:
甘特圖檢視 (Gantt):時間軸展示任務進度
看板檢視 (Kanban):敏捷開發工作流程管理
月曆檢視 (Calendar):時間導向的任務檢視
表格檢視 (Table):詳細的資料列表模式
專案標頭管理:
專案經理、名稱、目的等基本資訊
支援即時編輯功能 (點擊編輯)
鍵盤快速鍵支援 (Enter 確認、Escape 取消)
月曆整合:
使用 FullCalendar 提供專業的日曆功能
任務狀態色彩編碼
月檢視和週檢視切換
中文化界面
互動功能:
任務搜尋和篩選
多選任務操作
拖拽排序 (部分檢視)
內聯編輯
Domain Model (資料模型) 基於
useOppmData.js的專案資料結構:核心實體 (Core Entities)
專案 (Projects)
{ "id": "proj-001", "name": "優化物流請款作業" }專案資訊 (Project Info)
{ "name": "優化物流請款作業", "purpose": "透過系統化與自動化,簡化請款流程,降低人工作業錯誤率,並加速應收帳款回收週期。", "benefits": "預期能節省 50% 的人工對帳時間,並將帳款回收天數 (DSO) 縮短 5 天。", "projectManager": "陳經理" }任務 (Tasks)
{ "id": 1, "name": "專案啟動會議", "startDate": "2025-05-05", "endDate": "2025-05-06", "status": "completed", "ownerIds": [101], "relatedGoalIds": [1, 2, 3], "kanbanColumnId": 4, "priority": "high" }專案成員 (People)
{ "id": 101, "name": "陳經理", "role": "PM" }專案目標 (Goals)
{ "id": 1, "name": "流程自動化" }看板欄位 (Kanban Columns)
{ "id": 1, "title": "待辦事項 (To Do)", "color": "#ebecf0" }評論 (Comments)
{ "id": "c1", "taskId": 1, "authorId": 101, "text": "會議記錄已發送,請查收。", "timestamp": "2025-05-06T14:30:00" }風險 (Risks)
{ "id": "A", "title": "A. 資訊安全風險" }風險狀態追蹤 (Risk Status Data)
{ "B": { "2025-09-21": "red", "2025-09-28": "red" } }
業務規則 (Business Rules)
任務狀態管理:
completed:已完成任務 (綠色)critical:關鍵任務 (紅色)planned:計劃中任務 (藍色)future:未來任務 (灰色)
看板工作流程:
待辦事項 → 進行中 → 待審核 → 已完成
支援自訂欄位和顏色
時間軸設定:
專案開始:2025-05-04
專案結束:2026-02-01
優先級管理:
high:高優先級medium:中等優先級low:低優先級
三、環境設定:開發和生產環境
Vite 配置調整 首先調整 Vite 配置以支援開發和生產環境的不同需求:
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' export default defineConfig(({ command, mode }) => { const isDev = command === 'serve' const isProd = mode === 'production' return { plugins: [vue()], base: isProd ? '/Content/dist/' : '/', build: { outDir: 'dist', assetsDir: 'assets', sourcemap: isDev, rollupOptions: { input: { main: resolve(__dirname, 'index.html') }, output: { // 為了與 Serenity 整合,確保資源路徑正確 assetFileNames: 'assets/[name].[hash][extname]', chunkFileNames: 'assets/[name].[hash].js', entryFileNames: 'assets/[name].[hash].js' } } }, server: { port: 3000, proxy: { // 開發環境代理 API 請求到 Serenity 後端 '/api': { target: 'https://localhost:44300', // 調整為您的 Serenity 開發伺服器地址 changeOrigin: true, secure: false, rewrite: (path) => path.replace(/^\/api/, '/Services') } } }, define: { // 環境變數 __DEV__: isDev, __API_BASE__: isDev ? JSON.stringify('http://localhost:3000/api') : JSON.stringify('/Services') } } })環境配置文件 創建不同環境的配置文件:
Bash
# .env.development VITE_APP_TITLE=OPPM Development VITE_API_BASE_URL=http://localhost:3000/api VITE_SERENITY_BASE_URL=https://localhost:44300 # .env.production VITE_APP_TITLE=OPPM Production VITE_API_BASE_URL=/Services VITE_SERENITY_BASE_URL=打包腳本設定 更新
package.json添加打包和部署腳本:{ "scripts": { "dev": "vite", "build": "vite build", "build:dev": "vite build --mode development", "preview": "vite preview", "build:serenity": "npm run build && npm run copy-to-serenity", "copy-to-serenity": "node scripts/copy-dist.js" } }自動化部署腳本 創建自動複製到 Serenity 專案的腳本:
// scripts/copy-dist.js const fs = require('fs-extra') const path = require('path') const distPath = path.resolve(__dirname, '../dist') const serenityContentPath = path.resolve(__dirname, '../../../YourSerenityProject/Content/dist') // 調整為實際路徑 async function copyDistToSerenity() { try { // 確保目標目錄存在 await fs.ensureDir(serenityContentPath) // 複製 dist 目錄到 Serenity 專案 await fs.copy(distPath, serenityContentPath, { overwrite: true, filter: (src) => { // 過濾不需要的文件 return !src.includes('.map') || process.env.NODE_ENV !== 'production' } }) console.log(' 成功複製 dist 到 Serenity 專案') console.log(`目標路徑: ${serenityContentPath}`) } catch (error) { console.error('複製失敗:', error) process.exit(1) } } copyDistToSerenity()
四、CSRF Token 處理
什麼是 CSRF Token?
CSRF (Cross-Site Request Forgery) 跨站請求偽造
CSRF 是一種網路攻擊手法,攻擊者誘導受害者在已登入的網站上執行非預期的操作。
攻擊流程示例:
使用者登入銀行網站
bank.com攻擊者製作惡意網頁,包含轉帳請求:
<img src="https://bank.com/transfer?to=attacker&amount=1000" />使用者訪問惡意網頁時,瀏覽器自動發送轉帳請求
由於使用者仍處於登入狀態,銀行網站執行轉帳操作
CSRF Token 的作用機制
CSRF Token 是一個隨機、唯一且不可預測的值,用於驗證請求是否來自合法來源:
防護流程:
伺服器生成 Token: 為每個會話或表單生成唯一的 CSRF Token
嵌入表單: 將 Token 嵌入 HTML 表單或透過 API 提供
驗證請求: 伺服器檢查每個非安全請求(POST、PUT、DELETE)是否包含有效的 Token
拒絕無效請求: 沒有 Token 或 Token 無效的請求被拒絕
為什麼有效?
攻擊者無法取得受害者頁面中的 CSRF Token
同源政策阻止跨域腳本讀取 Token 值
每個 Token 都是唯一且時效性的
Serenity 框架中的 CSRF 設定
啟用 CSRF 保護 在 Serenity 專案中啟用 CSRF 保護:
// Startup.cs 或 Program.cs public void ConfigureServices(IServiceCollection services) { // 啟用防偽造 Token services.AddAntiforgery(options => { options.HeaderName = "X-CSRF-Token"; // 自訂 Header 名稱 options.Cookie.Name = "__RequestVerificationToken"; // Cookie 名稱 options.Cookie.HttpOnly = true; // 防止 JavaScript 存取 options.Cookie.SameSite = SameSiteMode.Strict; // 嚴格同站政策 options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // HTTPS only options.RequireSsl = true; // 需要 SSL }); // Serenity 相關設定 services.AddSerenity(Configuration); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { // ... 其他中介軟體 ... // 啟用防偽造驗證 app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); // ... 其他設定 ... }創建 CSRF Token 提供者 建立專門的 API 端點來提供 CSRF Token:
// Controllers/CSRFController.cs [ApiController] [Route("api/[controller]")] public class CSRFController : ControllerBase { private readonly IAntiforgery _antiforgery; public CSRFController(IAntiforgery antiforgery) { _antiforgery = antiforgery; } /// <summary> /// 獲取 CSRF Token /// </summary> [HttpGet("token")] public IActionResult GetCSRFToken() { var tokens = _antiforgery.GetAndStoreTokens(HttpContext); return Ok(new { token = tokens.RequestToken, headerName = "X-CSRF-Token" }); } }在 Controller 中啟用 CSRF 驗證 為需要保護的 API Controller 添加 CSRF 驗證:
// Controllers/KanbanController.cs [ApiController] [Route("api/[controller]")] [ValidateAntiForgeryToken] // 全 Controller 啟用 public class KanbanController : ControllerBase { // ... 現有代碼 ... [HttpPost("columns")] [ValidateAntiForgeryToken] // 特定 Action 啟用 public async Task<ActionResult<ApiResponse<KanbanColumn>>> CreateColumn([FromBody] CreateKanbanColumnDto dto) { // ... 實作邏輯 ... } [HttpPut("columns/{id}")] [ValidateAntiForgeryToken] public async Task<ActionResult<ApiResponse<KanbanColumn>>> UpdateColumn(int id, [FromBody] UpdateKanbanColumnDto dto) { // ... 實作邏輯 ... } [HttpDelete("columns/{id}")] [ValidateAntiForgeryToken] public async Task<ActionResult<ApiResponse<bool>>> DeleteColumn(int id) { // ... 實作邏輯 ... } }在 Serenity 頁面中嵌入 Token 更新 Serenity 頁面模板以提供 CSRF Token:
@{ ViewData["Title"] = "OPPM 看板"; // 生成防偽造 Token var antiforgeryTokenSet = Html.AntiForgeryToken(); } <div id="oppm-app"></div> <form id="csrf-form" style="display: none;"> @Html.AntiForgeryToken() </form> <meta name="csrf-token" content="@Html.AntiForgeryToken().ToString()"> <script> window.csrfToken = '@Html.AntiForgeryToken().ToString()'; window.csrfHeaderName = 'X-CSRF-Token'; </script> @section Head { <link rel="stylesheet" href="~/Content/dist/assets/index.css" /> } @section Scripts { <script type="module" src="~/Content/dist/assets/index.js"></script> }
Vue CSRF Token 管理器 建立完整的 CSRF Token 管理機制:
// src/utils/apiConfig.js const isDevelopment = import.meta.env.DEV const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || (isDevelopment ? '/api' : '/Services') export const API_CONFIG = { baseURL: apiBaseUrl, timeout: 10000, headers: { 'Content-Type': 'application/json', } } / * CSRF Token 管理器 * 負責獲取、快取和管理 CSRF Token */ class CSRFTokenManager { constructor() { this.token = null this.tokenExpiry = null this.refreshPromise = null // 防止並發請求 } / * 獲取有效的 CSRF Token * @returns {Promise<string|null>} CSRF Token */ async getToken() { // 如果 token 還有效,直接返回 if (this.token && this.tokenExpiry && Date.now() < this.tokenExpiry) { return this.token } // 如果正在刷新 token,等待完成 if (this.refreshPromise) { return await this.refreshPromise } // 開始刷新 token this.refreshPromise = this._refreshToken() try { const token = await this.refreshPromise return token } finally { this.refreshPromise = null } } / * 刷新 CSRF Token * @private */ async _refreshToken() { try { // 方法 1: 從專用 API 端點獲取 const response = await fetch('/api/csrf/token', { method: 'GET', credentials: 'same-origin' }) if (response.ok) { const data = await response.json() this.token = data.token this.tokenExpiry = Date.now() + (30 * 60 * 1000) // 30 分鐘 return this.token } } catch (error) { console.warn('從 API 端點獲取 CSRF Token 失敗:', error) } // 方法 2: 從頁面元素獲取 const pageToken = this._getTokenFromPage() if (pageToken) { this.token = pageToken this.tokenExpiry = Date.now() + (30 * 60 * 1000) return this.token } // 方法 3: 從任意 Serenity 端點獲取(最後手段) try { const response = await fetch('/Services/Administration/UserPermission/List', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({}), credentials: 'same-origin' }) // 從響應頭獲取 token const token = response.headers.get('X-CSRF-Token') || response.headers.get('RequestVerificationToken') if (token) { this.token = token this.tokenExpiry = Date.now() + (30 * 60 * 1000) return this.token } } catch (error) { console.error('從 Serenity 端點獲取 CSRF Token 失敗:', error) } throw new Error('無法獲取 CSRF Token') } / * 從頁面元素獲取 CSRF Token * @private */ _getTokenFromPage() { // 從隱藏表單欄位獲取 const tokenInput = document.querySelector('input[name="__RequestVerificationToken"]') if (tokenInput && tokenInput.value) { return tokenInput.value } // 從 meta 標籤獲取 const metaToken = document.querySelector('meta[name="csrf-token"]') if (metaToken) { const content = metaToken.getAttribute('content') if (content && content !== '@Html.AntiForgeryToken().ToString()') { return content } } // 從全域變數獲取 if (window.csrfToken && window.csrfToken !== '@Html.AntiForgeryToken().ToString()') { return window.csrfToken } // 從 Serenity 的全域物件獲取 if (window.Q && window.Q.Authorization && window.Q.Authorization.antiForgeryToken) { return window.Q.Authorization.antiForgeryToken } return null } / * 清除快取的 token(登出或錯誤時調用) */ clearToken() { this.token = null this.tokenExpiry = null this.refreshPromise = null } } const csrfManager = new CSRFTokenManager() / * API 請求客戶端 * 自動處理 CSRF Token 和錯誤重試 */ export const createApiClient = () => { const request = async (url, options = {}) => { const fullUrl = `${API_CONFIG.baseURL}${url}` let headers = { ...API_CONFIG.headers, ...options.headers } if (options.method && options.method !== 'GET') { try { const csrfToken = await csrfManager.getToken() if (csrfToken) { headers['X-CSRF-Token'] = csrfToken headers['RequestVerificationToken'] = csrfToken } } catch (error) { console.error('CSRF Token 獲取失敗:', error) } } const config = { ...options, headers, credentials: 'same-origin' } try { const response = await fetch(fullUrl, config) if (response.status === 400 || response.status === 403) { const responseText = await response.text() if (responseText.includes('RequestVerificationToken') || responseText.includes('CSRF')) { console.warn('CSRF Token 驗證失敗,嘗試重新獲取並重試') csrfManager.clearToken() try { const newToken = await csrfManager.getToken() if (newToken) { headers['X-CSRF-Token'] = newToken headers['RequestVerificationToken'] = newToken const retryResponse = await fetch(fullUrl, { ...config, headers }) if (!retryResponse.ok) throw new Error(`HTTP error! status: ${retryResponse.status}`) return await retryResponse.json() } } catch (retryError) { console.error('重試請求失敗:', retryError) throw new Error('CSRF Token 驗證失敗且重試失敗') } } } if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) return await response.json() } catch (error) { console.error('API request failed:', error) throw error } } return { get: (url, options) => request(url, { method: 'GET', ...options }), post: (url, data, options) => request(url, { method: 'POST', body: JSON.stringify(data), ...options }), put: (url, data, options) => request(url, { method: 'PUT', body: JSON.stringify(data), ...options }), delete: (url, options) => request(url, { method: 'DELETE', ...options }), } } export const apiClient = createApiClient()Serenity 整合工具 建立與 Serenity 框架深度整合的工具類:
// src/utils/serenityIntegration.js / * Serenity 框架整合工具 * 提供與 Serenity 環境的無縫整合功能 */ export class SerenityIntegration { constructor() { this.isEmbedded = this.checkIfEmbedded() } / * 檢查是否在 Serenity 環境中運行 * @returns {boolean} 是否在 Serenity 環境中 */ checkIfEmbedded() { return typeof window !== 'undefined' && ( window.Q !== undefined || document.querySelector('script[src*="Serenity"]') !== null ) } / * 獲取當前用戶資訊(從 Serenity) * @returns {Object|null} 用戶資訊 */ getCurrentUser() { if (window.Q && window.Q.Authorization) { return { username: window.Q.Authorization.username, displayName: window.Q.Authorization.userDisplayName, } } return null } / * 檢查用戶權限 * @param {string} permission - 權限名稱 * @returns {boolean} 是否擁有權限 */ hasPermission(permission) { return window.Q?.Authorization.hasPermission(permission) ?? false } / * 顯示 Serenity 樣式的通知 * @param {string} message - 通知訊息 * @param {string} type - 通知類型 ('info'|'success'|'warning'|'error') */ showNotification(message, type = 'info', options = {}) { if (window.Q && window.Q.notifyInfo) { const notifyMethod = { 'info': window.Q.notifyInfo, 'success': window.Q.notifySuccess, 'warning': window.Q.notifyWarning, 'error': window.Q.notifyError }[type] || window.Q.notifyInfo notifyMethod(message, options.title, options) } else { alert(`[${type.toUpperCase()}] ${message}`) } } } export const serenityIntegration = new SerenityIntegration() export const PERMISSIONS = { KANBAN_READ: 'kanban:read', KANBAN_WRITE: 'kanban:write', ADMIN: 'admin:*' }
五、C# API 的定義
數據模型 (Models) 和 DTO
// Models/Kanban.cs public class KanbanColumn { public int Id { get; set; } public string Title { get; set; } public string Color { get; set; } public int Order { get; set; } } public class Task { public int Id { get; set; } public string Name { get; set; } public DateTime StartDate { get; set; } public DateTime EndDate { get; set; } public string Status { get; set; } public int KanbanColumnId { get; set; } public List<int> OwnerIds { get; set; } } public class Person { public int Id { get; set; } public string Name { get; set; } } // DTOs/KanbanDtos.cs public class CreateKanbanColumnDto { [Required, StringLength(255)] public string Title { get; set; } [Required, RegularExpression(@"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")] public string Color { get; set; } } public class UpdateTaskKanbanStatusDto { [Required] public int KanbanColumnId { get; set; } } // DTOs/ResponseDtos.cs public class ApiResponse<T> { public bool Success { get; set; } public string Message { get; set; } public T Data { get; set; } public List<string> Errors { get; set; } } public class KanbanBoardDto { public List<KanbanColumn> Columns { get; set; } public List<Task> Tasks { get; set; } public List<Person> People { get; set; } }API Controller
// Controllers/KanbanController.cs [ApiController] [Route("api/[controller]")] [ValidateAntiForgeryToken] public class KanbanController : ControllerBase { private readonly IKanbanService _kanbanService; public KanbanController(IKanbanService kanbanService) { _kanbanService = kanbanService; } [HttpGet] public async Task<ActionResult<ApiResponse<KanbanBoardDto>>> GetKanbanBoard() { var data = await _kanbanService.GetKanbanBoardAsync(); return Ok(new ApiResponse<KanbanBoardDto> { Success = true, Data = data }); } [HttpPost("columns")] public async Task<ActionResult<ApiResponse<KanbanColumn>>> CreateColumn([FromBody] CreateKanbanColumnDto dto) { var column = await _kanbanService.CreateColumnAsync(dto); return Ok(new ApiResponse<KanbanColumn> { Success = true, Data = column }); } [HttpPut("tasks/{taskId}/kanban-status")] public async Task<ActionResult<ApiResponse<Task>>> UpdateTaskKanbanStatus(int taskId, [FromBody] UpdateTaskKanbanStatusDto dto) { var task = await _kanbanService.UpdateTaskKanbanStatusAsync(taskId, dto.KanbanColumnId); return Ok(new ApiResponse<Task> { Success = true, Data = task }); } }服務介面
// Services/IKanbanService.cs public interface IKanbanService { Task<KanbanBoardDto> GetKanbanBoardAsync(); Task<KanbanColumn> CreateColumnAsync(CreateKanbanColumnDto dto); Task<Task> UpdateTaskKanbanStatusAsync(int taskId, int kanbanColumnId); }
六、Vue 的 API Wrapper
API 服務基礎類
// src/services/BaseApiService.js import { apiClient } from '../utils/apiConfig' import { serenityIntegration } from '../utils/serenityIntegration' export class BaseApiService { constructor(baseEndpoint = '') { this.baseEndpoint = baseEndpoint this.client = apiClient } async handleApiCall(apiCall, operation, showSuccessNotification = false) { try { const response = await apiCall if (response.success) { if (showSuccessNotification && response.message) { serenityIntegration.showNotification(response.message, 'success') } return response.data } else { throw new Error(response.message || `${operation}失敗`) } } catch (error) { this.handleError(error, operation) throw error } } handleError(error, operation) { console.error(`${operation} 失敗:`, error) const message = error.message || '未知錯誤' serenityIntegration.showNotification(`${operation}失敗: ${message}`, 'error') } buildEndpoint(endpoint) { return `${this.baseEndpoint}${endpoint}` } }Kanban API 服務
// src/services/KanbanApiService.js import { BaseApiService } from './BaseApiService' class KanbanApiService extends BaseApiService { constructor() { super('/kanban') } async getKanbanBoard() { return this.handleApiCall( this.client.get(this.baseEndpoint), '獲取看板數據' ) } async createColumn(columnData) { return this.handleApiCall( this.client.post(this.buildEndpoint('/columns'), columnData), '新增看板欄位', true ) } async updateTaskKanbanStatus(taskId, kanbanColumnId) { return this.handleApiCall( this.client.put(this.buildEndpoint(`/tasks/${taskId}/kanban-status`), { kanbanColumnId }), '更新任務看板狀態' ) } } export const kanbanApiService = new KanbanApiService()API 服務統一導出
// src/services/index.js export { kanbanApiService } from './KanbanApiService' export const apiServices = { kanban: kanbanApiService, } export const API_STATUS = { IDLE: 'idle', LOADING: 'loading', SUCCESS: 'success', ERROR: 'error' }
七、現有程式改成使用 API
更新 composable
// src/composables/useOppmData.js import { ref, reactive, computed } from 'vue' import { apiServices, API_STATUS } from '../services' export function useOppmData() { const apiStatus = ref(API_STATUS.IDLE) const error = ref(null) const oppmData = reactive({ tasks: [], people: [] }) const kanbanColumns = ref([]) const isLoading = computed(() => apiStatus.value === API_STATUS.LOADING) const setApiStatus = (status, errorMessage = null) => { apiStatus.value = status error.value = errorMessage } const initializeData = async () => { try { setApiStatus(API_STATUS.LOADING) const boardData = await apiServices.kanban.getKanbanBoard() if (boardData) { kanbanColumns.value = boardData.columns || [] oppmData.tasks = boardData.tasks || [] oppmData.people = boardData.people || [] } setApiStatus(API_STATUS.SUCCESS) } catch (err) { setApiStatus(API_STATUS.ERROR, err.message) } } const updateTaskKanbanStatus = async (taskId, kanbanColumnId) => { try { // Optimistic update const task = oppmData.tasks.find(t => t.id === taskId) const oldColumnId = task.kanbanColumnId if (task) { task.kanbanColumnId = kanbanColumnId } await apiServices.kanban.updateTaskKanbanStatus(taskId, kanbanColumnId) } catch (err) { // Revert on error const task = oppmData.tasks.find(t => t.id === taskId) if (task) { task.kanbanColumnId = oldColumnId } setApiStatus(API_STATUS.ERROR, err.message) } } return { oppmData, kanbanColumns, isLoading, error, initializeData, updateTaskKanbanStatus, } }更新主應用程式
// src/main.js import { createApp } from 'vue' import App from './App.vue' import { setupDevMocks } from './utils/devMocks' if (import.meta.env.DEV) { setupDevMocks() } createApp(App).mount('#oppm-app')開發環境模擬設定
// src/utils/devMocks.js export const setupDevMocks = () => { if (import.meta.env.DEV && typeof window !== 'undefined') { // 模擬 Serenity 的 Q 物件 window.Q = window.Q || { Authorization: { username: 'Developer', antiForgeryToken: 'dev-csrf-token-' + Date.now(), hasPermission: (p) => true, }, notifyInfo: (msg) => console.log('INFO:', msg), notifySuccess: (msg) => console.log('SUCCESS:', msg), notifyWarning: (msg) => console.warn('WARNING:', msg), notifyError: (msg) => console.error('ERROR:', msg), } // 模擬 CSRF Token 隱藏欄位 if (!document.querySelector('input[name="__RequestVerificationToken"]')) { const tokenInput = document.createElement('input') tokenInput.type = 'hidden' tokenInput.name = '__RequestVerificationToken' tokenInput.value = window.Q.Authorization.antiForgeryToken document.body.appendChild(tokenInput) } } }
八、測試和部署流程
開發環境測試
# 1. 啟動 Serenity 後端開發伺服器 # 2. 啟動 Vue 開發伺服器(帶 API 代理) npm run dev # 3. 訪問 http://localhost:3000 進行測試生產環境部署
# 1. 建立 Vue 生產版本 npm run build # 2. 複製到 Serenity 專案 npm run copy-to-serenity # 3. 部署整個 Serenity 專案Serenity 頁面整合
@{ ViewData["Title"] = "OPPM 看板"; } <div id="oppm-app"> <p>Loading OPPM Module...</p> </div> @Html.AntiForgeryToken() @section Head { <link rel="stylesheet" href="~/Content/dist/assets/index.css" /> } @section Scripts { <script type="module" src="~/Content/dist/assets/index.js"></script> }
九、總結
這個完整的整合方案提供了:
環境隔離: 開發和生產環境的完全分離與配置。
安全性: 完整的 CSRF Token 處理機制,防止跨站請求偽造。
類型安全: 透過 C# 的強類型和前端的參數驗證確保數據正確性。
錯誤處理: 統一的錯誤處理和與 Serenity 風格一致的用戶通知系統。
可維護性: 基於服務的清晰代碼結構,易於維護和擴展。
整合性: 透過整合工具,讓 Vue 應用能無縫利用 Serenity 的用戶認證、權限和本地化等功能。
透過這套方案,您可以在享受 Vue 3 現代開發體驗的同時,完全整合到現有的 C# Serenity 專案中,實現前後端的無縫協作。